1use crate::Xtask;
36use anyhow::Context;
37use clap::Parser;
38use grep_regex::RegexMatcher;
39use grep_regex::RegexMatcherBuilder;
40use grep_searcher::BinaryDetection;
41use grep_searcher::Searcher;
42use grep_searcher::SearcherBuilder;
43use grep_searcher::Sink;
44use grep_searcher::SinkMatch;
45use rayon::prelude::*;
46use std::error;
47use std::path::Path;
48use std::path::PathBuf;
49use std::str::FromStr;
50
51#[derive(Parser)]
52#[clap(about = "Detect any unused dependencies in Cargo.toml files")]
53#[clap(after_help = r#"NOTE:
54
55 False-positives can be suppressed by setting `package.metadata.xtask.unused-dep.ignored`
56 in the corresponding `Cargo.toml` file.
57
58 For example, "test-env-log" has implicit deps on both "env_logger" and "tracing-subscriber":
59
60 [package.metadata.xtask.unused-deps]
61 ignored = ["env_logger", "tracing-subscriber"]
62"#)]
63pub struct UnusedDeps {
64 #[clap(long)]
66 pub fix: bool,
67}
68
69impl Xtask for UnusedDeps {
70 fn run(self, ctx: crate::XtaskCtx) -> anyhow::Result<()> {
71 let entries = ignore::Walk::new(&ctx.root)
73 .filter_map(|entry| match entry {
74 Ok(entry) => {
75 if entry.file_name() == "Cargo.toml" {
76 Some(entry.into_path())
77 } else {
78 None
79 }
80 }
81 Err(err) => {
82 log::error!("error when walking over subdirectories: {}", err);
83 None
84 }
85 })
86 .collect::<Vec<_>>();
87
88 let mut results = entries
91 .par_iter()
92 .filter_map(|path| match analyze_crate(path) {
93 Ok(Some(analysis)) => Some((analysis, path)),
94
95 Ok(None) => {
96 log::debug!("{} is a virtual manifest for a workspace", path.display());
97 None
98 }
99
100 Err(err) => {
101 log::error!("error when handling {}: {}", path.display(), err);
102 None
103 }
104 })
105 .collect::<Vec<_>>();
106
107 results.sort_by(|a, b| a.1.cmp(b.1));
108
109 let mut workspace = analyze_workspace(&ctx.root)?;
110 let full_deps = workspace.deps.clone();
111
112 let mut found_something = false;
115 for (analysis, path) in results {
116 if !analysis.results.is_empty() {
117 found_something = true;
118 println!("{} -- {}:", analysis.package_name, path.display());
119 for result in &analysis.results {
120 match result {
121 DepResult::Unused(n) => println!("\t{} is unused", n),
122 DepResult::IgnoredButUsed(n) => {
123 println!("\t{} is ignored, but being used", n)
124 }
125 DepResult::IgnoredAndMissing(n) => {
126 println!("\t{} is ignored, but it's not even being depended on", n)
127 }
128 }
129 }
130
131 if self.fix {
132 let fixed =
133 remove_dependencies(&fs_err::read_to_string(path)?, &analysis.results)?;
134 fs_err::write(path, fixed).context("Cargo.toml write error")?;
135 }
136 }
137
138 workspace.deps.retain(|x| !analysis.deps.contains(x));
139 }
140
141 workspace.deps.sort();
142 workspace.ignored.sort();
143 if workspace.deps != workspace.ignored {
144 found_something = true;
145 let mut unused_deps = Vec::new();
146
147 println!("Workspace -- {}:", workspace.path.display());
148 for dep in &workspace.deps {
149 if !workspace.ignored.contains(dep) {
150 println!("\t{} is unused", dep);
151 unused_deps.push(DepResult::Unused(dep.clone()));
152 }
153 }
154 for ign in &workspace.ignored {
155 if !workspace.deps.contains(ign) {
156 if full_deps.contains(ign) {
157 println!("\t{} is ignored, but being used", ign);
158 unused_deps.push(DepResult::IgnoredButUsed(ign.clone()));
159 } else {
160 println!("\t{} is ignored, but it's not even being depended on", ign);
161 unused_deps.push(DepResult::IgnoredAndMissing(ign.clone()));
162 }
163 }
164 }
165
166 if self.fix {
167 let fixed =
168 remove_dependencies(&fs_err::read_to_string(&workspace.path)?, &unused_deps)?;
169 fs_err::write(&workspace.path, fixed).context("Cargo.toml write error")?;
170 }
171 }
172
173 if found_something && !self.fix {
174 Err(anyhow::anyhow!("found dependency issues"))
175 } else {
176 Ok(())
177 }
178 }
179}
180
181fn remove_dependencies(manifest: &str, analysis_results: &[DepResult]) -> anyhow::Result<String> {
182 let mut manifest = toml_edit::DocumentMut::from_str(manifest)?;
183
184 let mut unused_deps = Vec::new();
185 let mut ignored_and_shouldnt_be = Vec::new();
186
187 for res in analysis_results {
188 match res {
189 DepResult::Unused(n) => unused_deps.push(n),
190 DepResult::IgnoredButUsed(n) => ignored_and_shouldnt_be.push(n),
191 DepResult::IgnoredAndMissing(n) => ignored_and_shouldnt_be.push(n),
192 }
193 }
194
195 let mut features_table = None;
196 let mut dep_tables = Vec::new();
197 let mut ignored_array = None;
198 for (k, v) in manifest.iter_mut() {
199 let v = match v {
200 v if v.is_table_like() => v.as_table_like_mut().unwrap(),
201 _ => continue,
202 };
203
204 match k.get() {
205 "dependencies" | "build-dependencies" | "dev-dependencies" => dep_tables.push(v),
206 "target" => {
207 let flattened = v.iter_mut().flat_map(|(_, v)| {
208 v.as_table_like_mut()
209 .expect("conforms to cargo schema")
210 .iter_mut()
211 });
212
213 for (k, v) in flattened {
214 let v = match v {
215 v if v.is_table_like() => v.as_table_like_mut().unwrap(),
216 _ => continue,
217 };
218
219 match k.get() {
220 "dependencies" | "build-dependencies" | "dev-dependencies" => {
221 dep_tables.push(v)
222 }
223 _ => {}
224 }
225 }
226 }
227 "workspace" => {
228 for (k2, v2) in v.iter_mut() {
229 let v2 = match v2 {
230 v2 if v2.is_table_like() => v2.as_table_like_mut().unwrap(),
231 _ => continue,
232 };
233
234 match k2.get() {
235 "dependencies" => dep_tables.push(v2),
236 "metadata" => {
237 if v2
241 .get("xtask")
242 .and_then(|x| x.get("unused-deps"))
243 .and_then(|u| u.get("ignored"))
244 .is_some()
245 {
246 ignored_array = v2
247 .get_mut("metadata")
248 .unwrap()
249 .get_mut("xtask")
250 .unwrap()
251 .get_mut("unused-deps")
252 .unwrap()
253 .get_mut("ignored")
254 .unwrap()
255 .as_array_mut();
256 }
257 }
258 _ => {}
259 }
260 }
261 }
262 "package" => {
263 if v.get("metadata")
267 .and_then(|m| m.get("xtask"))
268 .and_then(|x| x.get("unused-deps"))
269 .and_then(|u| u.get("ignored"))
270 .is_some()
271 {
272 ignored_array = v
273 .get_mut("metadata")
274 .unwrap()
275 .get_mut("xtask")
276 .unwrap()
277 .get_mut("unused-deps")
278 .unwrap()
279 .get_mut("ignored")
280 .unwrap()
281 .as_array_mut();
282 }
283 }
284 "features" => features_table = Some(v),
285 _ => {}
286 }
287 }
288
289 for i in ignored_and_shouldnt_be {
290 let ignored_array = ignored_array
291 .as_mut()
292 .expect("must have an ignored array for IgnoredButUsed results to appear");
293 let index = ignored_array
294 .iter()
295 .position(|v| v.as_str() == Some(i))
296 .expect("must find items that were found in previous pass");
297 ignored_array.remove(index);
298 }
299
300 if let Some(features_table) = features_table {
301 for (_feature_name, feature_deps) in features_table.iter_mut() {
302 let mut to_remove = Vec::new();
303 let feature_deps = feature_deps
304 .as_array_mut()
305 .expect("feature dependencies must be an array");
306 for index in 0..feature_deps.len() {
307 let feature_dep_name = feature_deps
308 .get(index)
309 .unwrap()
310 .as_str()
311 .expect("feature dependencies must be strings");
312 let feature_dep_name = feature_dep_name
313 .strip_prefix("dep:")
314 .unwrap_or(feature_dep_name);
315 for unused in &unused_deps {
316 if feature_dep_name.starts_with(&**unused)
317 && (feature_dep_name.len() == unused.len()
318 || matches!(feature_dep_name.as_bytes()[unused.len()], b'/' | b'?'))
319 {
320 to_remove.push(index);
321 }
322 }
323 }
324 for i in to_remove.into_iter().rev() {
325 feature_deps.remove(i);
326 }
327 }
328 }
329
330 for dep_table in dep_tables {
331 unused_deps.retain(|dep| dep_table.remove(dep).is_none());
332 }
333 assert!(unused_deps.is_empty());
334
335 let serialized = manifest.to_string();
336 Ok(serialized)
337}
338
339mod meta {
340 use serde::Deserialize;
341 use serde::Serialize;
342
343 #[derive(Serialize, Deserialize)]
344 pub struct PackageMetadata {
345 pub xtask: Option<Xtask>,
346 }
347 #[derive(Serialize, Deserialize)]
348 pub struct Xtask {
349 #[serde(rename = "unused-deps")]
350 pub unused_deps: Option<Ignored>,
351 }
352
353 #[derive(Serialize, Deserialize)]
354 pub struct Ignored {
355 pub ignored: Vec<String>,
356 }
357}
358
359type Manifest = cargo_toml::Manifest<meta::PackageMetadata>;
360
361struct PackageAnalysis {
362 pub package_name: String,
363 pub results: Vec<DepResult>,
364 pub deps: Vec<String>,
365}
366
367#[derive(PartialEq, Eq, PartialOrd, Ord)]
368enum DepResult {
369 Unused(String),
371 IgnoredButUsed(String),
373 IgnoredAndMissing(String),
375}
376
377struct WorkspaceAnalysis {
378 pub path: PathBuf,
379 pub deps: Vec<String>,
380 pub ignored: Vec<String>,
381}
382
383fn make_regexp(name: &str) -> String {
384 format!(r#"use (::)?{name}(::|;| as)|\b{name}::|extern crate {name}( |;)"#)
391}
392
393fn collect_paths(dir_path: &Path, manifest: &Manifest) -> Vec<PathBuf> {
395 let mut root_paths = Vec::new();
396
397 if let Some(path) = manifest.lib.as_ref().and_then(|lib| lib.path.as_ref()) {
398 assert!(
399 path.ends_with(".rs"),
400 "paths provided by cargo_toml are to Rust files"
401 );
402 let mut path_buf = PathBuf::from(path);
403 path_buf.pop();
405 root_paths.push(path_buf);
406 }
407
408 for product in (manifest.bin.iter())
409 .chain(manifest.bench.iter())
410 .chain(manifest.test.iter())
411 .chain(manifest.example.iter())
412 {
413 if let Some(ref path) = product.path {
414 assert!(
415 path.ends_with(".rs"),
416 "paths provided by cargo_toml are to Rust files"
417 );
418 let mut path_buf = PathBuf::from(path);
419 path_buf.pop();
421 root_paths.push(path_buf);
422 }
423 }
424
425 log::trace!("found root paths: {:?}", root_paths);
426
427 if root_paths.is_empty() {
428 root_paths.push(PathBuf::from("src"));
430 log::trace!("adding src/ since paths was empty");
431 }
432
433 let mut paths: Vec<PathBuf> = root_paths
435 .iter()
436 .flat_map(|root| ignore::Walk::new(dir_path.join(root)))
437 .filter_map(|result| {
438 let dir_entry = match result {
439 Ok(dir_entry) => dir_entry,
440 Err(err) => {
441 log::error!("{}", err);
442 return None;
443 }
444 };
445
446 if !dir_entry.file_type().unwrap().is_file() {
447 return None;
448 }
449
450 if dir_entry
451 .path()
452 .extension()
453 .is_none_or(|ext| ext.to_str() != Some("rs"))
454 {
455 return None;
456 }
457
458 Some(dir_entry.path().to_owned())
459 })
460 .collect();
461
462 let build_rs = dir_path.join("build.rs");
463 if build_rs.exists() {
464 paths.push(build_rs);
465 }
466
467 log::trace!("found transitive paths: {:?}", paths);
468
469 paths
470}
471
472struct Search {
473 matcher: RegexMatcher,
474 searcher: Searcher,
475 sink: StopAfterFirstMatch,
476}
477
478impl Search {
479 fn new(crate_name: &str) -> anyhow::Result<Self> {
480 let snaked = crate_name.replace('-', "_");
481 let pattern = make_regexp(&snaked);
482 let matcher = RegexMatcherBuilder::new()
483 .multi_line(true)
484 .build(&pattern)?;
485
486 let searcher = SearcherBuilder::new()
487 .binary_detection(BinaryDetection::quit(b'\x00'))
488 .line_number(false)
489 .build();
490
491 let sink = StopAfterFirstMatch::new();
492
493 Ok(Self {
494 matcher,
495 searcher,
496 sink,
497 })
498 }
499
500 fn search_path(&mut self, path: &Path) -> anyhow::Result<bool> {
501 self.searcher
502 .search_path(&self.matcher, path, &mut self.sink)
503 .map_err(|err| anyhow::anyhow!("when searching: {}", err))
504 .map(|_| self.sink.found)
505 }
506}
507
508fn analyze_workspace(root: &Path) -> anyhow::Result<WorkspaceAnalysis> {
509 let path = root.join("Cargo.toml");
510 let manifest = Manifest::from_path_with_metadata(&path)?;
511 let workspace = manifest
512 .workspace
513 .expect("workspace manifest must have a workspace section");
514
515 let deps = workspace.dependencies.into_keys().collect();
516
517 let ignored = workspace
518 .metadata
519 .and_then(|meta| meta.xtask.and_then(|x| x.unused_deps.map(|u| u.ignored)))
520 .unwrap_or_default();
521
522 Ok(WorkspaceAnalysis {
523 deps,
524 path,
525 ignored,
526 })
527}
528
529fn analyze_crate(manifest_path: &Path) -> anyhow::Result<Option<PackageAnalysis>> {
530 let mut dir_path = manifest_path.to_path_buf();
531 dir_path.pop();
532
533 log::trace!("trying to open {}...", manifest_path.display());
534
535 let mut manifest = Manifest::from_path_with_metadata(manifest_path)?;
536 let package_name = match manifest.package {
537 Some(ref package) => package.name.clone(),
538 None => return Ok(None),
539 };
540
541 log::debug!("handling {} ({})", package_name, dir_path.display());
542
543 manifest.complete_from_path(manifest_path)?;
544
545 let paths = collect_paths(&dir_path, &manifest);
546
547 let mut deps = Vec::new();
548
549 deps.extend(manifest.dependencies.keys().cloned());
550 deps.extend(manifest.build_dependencies.keys().cloned());
551 deps.extend(manifest.dev_dependencies.keys().cloned());
552 for target in manifest.target.iter() {
553 deps.extend(target.1.dependencies.keys().cloned());
554 deps.extend(target.1.build_dependencies.keys().cloned());
555 deps.extend(target.1.dev_dependencies.keys().cloned());
556 }
557
558 let ignored = if let Some(unused_deps) = manifest
559 .package
560 .and_then(|package| package.metadata)
561 .and_then(|meta| meta.xtask.and_then(|x| x.unused_deps))
562 {
563 unused_deps.ignored
564 } else {
565 Vec::new()
566 };
567
568 let mut results = deps
569 .par_iter()
570 .filter_map(|name| {
571 let mut search = Search::new(name).expect("constructing grep context");
572
573 let mut found_once = false;
574 for path in &paths {
575 log::trace!("looking for {} in {}", name, path.to_string_lossy());
576 match search.search_path(path) {
577 Ok(true) => {
578 found_once = true;
579 break;
580 }
581 Ok(false) => {}
582 Err(err) => {
583 log::error!("{}: {}", path.display(), err);
584 }
585 };
586 }
587
588 let ignored = ignored.contains(name);
589
590 match (found_once, ignored) {
591 (true, true) => Some(DepResult::IgnoredButUsed(name.into())),
592 (true, false) => None,
593 (false, true) => None,
594 (false, false) => Some(DepResult::Unused(name.into())),
595 }
596 })
597 .collect::<Vec<_>>();
598
599 for i in &ignored {
600 if !deps.contains(i) {
601 results.push(DepResult::IgnoredAndMissing(i.clone()));
602 }
603 }
604
605 results.sort();
606
607 Ok(Some(PackageAnalysis {
608 package_name,
609 results,
610 deps,
611 }))
612}
613
614struct StopAfterFirstMatch {
615 found: bool,
616}
617
618impl StopAfterFirstMatch {
619 fn new() -> Self {
620 Self { found: false }
621 }
622}
623
624impl Sink for StopAfterFirstMatch {
625 type Error = Box<dyn error::Error>;
626
627 fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result<bool, Self::Error> {
628 let mat = String::from_utf8(mat.bytes().to_vec())?;
629 let mat = mat.trim();
630
631 if mat.starts_with("//") || mat.starts_with("//!") {
632 return Ok(true);
636 }
637
638 self.found = true;
641 Ok(false)
642 }
643}